Skip to content

S03-09 核心类-Java比较器

[TOC]

概述

在 Java 中,当我们需要对一组对象进行排序时(比如一个存储了自定义 User 对象的 List),Java 并不知道应该按照什么规则去排——是按年龄、按名字,还是按入职时间?

为了解决这个问题,Java 提供了两个核心接口:ComparableComparator。它们被称为 Java 的比较器。

Comparable 接口

Comparable(自然排序 / 内部比较器)

在 Java 中,如果你有一组自定义对象(比如用户、商品或学生),想直接使用 Collections.sort()Arrays.sort() 对它们进行排序,Java 会面临一个问题:它不知道应该按照哪个属性来排

这就是 Comparable 接口的核心作用。它用于为类定义自然排序(Natural Ordering)规则。换句话说,实现这个接口就等于告诉 Java:“我们这个类的对象,默认就应该按照这种规则来比大小。”

compareTo()

核心方法:compareTo:

Comparable<T> 接口非常简单,内部只有一个抽象方法需要你实现:

java
public interface Comparable<T>{
    int compareTo(T o);
}

这个方法的返回值是一个整数,它的核心逻辑如下(假设我们要比较 A.compareTo(B)):

  • 返回负整数(通常是 -1):表示 A 小于 BA 应该排在 B 前面)。
  • 返回零0):表示 A 等于 B
  • 返回正整数(通常是 1):表示 A 大于 BA 应该排在 B 后面)。

实战案例

实战演练:让 User 类支持按年龄排序:

我们来看一个具体的例子。假设有一个 User 类,我们希望默认按照年龄从小到大升序排列:

java
// 1. 实现 Comparable 接口
public class User implements Comparable<User> {  
  private String name;
  private int age;

  public User(String name, int age) {
    this.name = name;
    this.age = age;
  }

  // 2. 实现 Comparable 接口的抽象方法 compareTo()
  @Override
  public int compareTo(User other) {  
    // 升序排列:当前对象年龄减去对比对象年龄
    return Integer.compare(this.age, other.age);
  }

  @Override
  public String toString() {
    return name + "(" + age + ")";
  }
}

为什么推荐 Integer.compare():

避坑指南: 过去很多人喜欢直接写 return this.age - other.age;。这种写法虽然简洁,但在处理大整数时(比如 this.age 是正数,other.age 是负数)可能会导致整数溢出,从而产生完全相反的排序结果。使用包装类的 .compare() 方法是最安全的选择。

现在我们可以直接对 User 列表排序了:

java
List<User> users = new ArrayList<>();
users.add(new User("张三", 25));
users.add(new User("李四", 20));
users.add(new User("王五", 30));

Collections.sort(users); // 会自动调用 User 类中的 compareTo
System.out.println(users); // 输出: [李四(20), 张三(25), 王五(30)]

核心规则

必须遵守的“军规”(Contract):

实现 Comparable 时,有几个虽然编译器不强制、但写代码必须遵守的原则,否则在使用 TreeSetTreeMap 等集合时会发生不可预知的 Bug:

  1. 对称性:如果 A.compareTo(B) > 0,那么 B.compareTo(A) 必须小于 0。

  2. 传递性:如果 A > BB > C,那么必须有 A > C

  3. equals 保持一致(强烈建议):如果 A.compareTo(B) == 0,那么 A.equals(B) 最好也返回 true。因为诸如 TreeSet 这类集合在去重时,是用 compareTo 来判断元素是否重复的,而不是 equals

Comparator 接口

Comparator(定制排序 / 外部比较器)

如果说 Comparable 是给类穿上了一件“出厂自带”的固定衣服,那么 Comparator 就是更衣室里的百变战袍。


实战痛点

在实际开发中,我们经常遇到两种痛点:

  1. 源码改不动:比如你想对 String 或第三方 Jar 包里的类排序,你没法修改人家的源码去实现 Comparable 接口。

  2. 规则总在变:比如商品列表,用户一会儿想按“价格从低到高”排,一会儿想按“销量从高到低”排。一个类只能实现一个 Comparable 方法,根本应付不来。

这时候,就需要 Comparator 闪亮登场了。它就像一个独立的裁判(工具类),不需要修改类本身,只需要你在排序时把它作为参数传进去即可。

compare()

核心方法:compare():

Comparator<T> 是一个函数式接口(Functional Interface),它的核心方法是:

java
public interface Comparator<T>{
    int compare(T o1, T o2);
}

这个方法的返回值是一个整数,它的核心逻辑如下(假设我们要比较 compare(o1, o2)):

  • 返回负整数(通常是 -1):表示 o1 小于 o2o1 应该排在 o2 前面)。
  • 返回零0):表示 o1 等于 o2
  • 返回正整数(通常是 1):表示 o1 大于 o2o1 应该排在 o2 后面)。

传统写法

Java 8 之前如果你想自定义排序规则,主要有两种流水线式的写法:匿名内部类独立实现类。同时,排序只能依靠 Collections.sort() 这个静态方法。

写法一:匿名内部类

匿名内部类(“临时”写法)

如果你只需要在某一个地方临时对列表排个序,最常见的就是直接在 Collections.sort() 里现场“手写”一个 Comparator 的匿名内部类。

假设我们依然有一个 Student 类(有 namescore 属性),在 Java 7 或更早的版本里,按分数升序排列必须这样写:

java
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

// 假设这是当年的一段业务代码
List<Student> students = new ArrayList<Student>();
students.add(new Student("张三", 85));
students.add(new Student("李四", 92));

// Java 8 之前没有 list.sort(),必须用 Collections.sort()
Collections.sort(students, new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        // 经典的包装类安全比较
        return Integer.compare(o1.getScore(), o2.getScore());
    }
});

匿名内部类写法的痛点

核心逻辑其实只有 Integer.compare(...) 这一行,但为了这一行,你必须硬生生附带上 new Comparator<Student>()@Overridepublic int compare... 等整整 5 行外壳。这种为了传递一个“动作”(比较逻辑),不得不包裹一个“对象”(匿名内部类)的做法,正是当年 Java 被诟病“罗嗦”的主要原因。

写法二:独立实现类

独立实现类(用于复用):

如果某一种排序规则在好几个地方都要用(比如“按学生分数降序排列”),匿名内部类就不合适了。那时我们会专门写一个类去实现 Comparator 接口:

java
// 1. 专门定义一个比较器类
public class StudentScoreDescComparator implements Comparator<Student> {
    @Override
    public int compare(Student o1, Student o2) {
        // 降序:o2 放前面
        return Integer.compare(o2.getScore(), o1.getScore());
    }
}

// 2. 在业务代码中使用
List<Student> students = new ArrayList<Student>();
// ... 添加数据 ...

// 传入比较器的实例
Collections.sort(students, new StudentScoreDescComparator());

灾难现场:多条件组合排序

灾难现场:多条件组合排序:

在 Java 8 之前,没有像 .thenComparing() 这样优雅的链式调用。如果你想实现“先按分数降序,分数相同再按名字字母升序”这种多条件排序,所有的逻辑都必须揉在一个 compare 方法里,形成复杂的嵌套:

java
Collections.sort(students, new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        // 1. 先比分数(降序)
        int scoreResult = Integer.compare(o2.getScore(), o1.getScore());

        // 2. 如果分数一样,再比名字
        if (scoreResult == 0) {
            if (o1.getName() == null && o2.getName() == null) return 0;
            if (o1.getName() == null) return -1;
            if (o2.getName() == null) return 1;
            return o1.getName().compareTo(o2.getName());
        }

        return scoreResult;
    }
});

这种代码不仅阅读起来容易眼花,一旦排序条件增加到三四个,内部的 if-else 就会像套娃一样可怕,极易翻车。

传统写法的局限

回顾 Java 8 之前的 Comparator,主要有以下三个硬伤:

  1. 语法大国,废话太多:为了传一个函数,必须写一个类。

  2. 集合自身无法排序List 接口当时没有 sort() 方法,必须通过外部工具类 Collections.sort(list, comparator) 来间接完成。

  3. 缺乏组合拳能力:没有提供诸如 reversed()thenComparing()nullsFirst() 等工具方法,所有稍微复杂的复合逻辑、逆序逻辑、判空逻辑,全部需要程序员纯手工用 if-elsecompare 内部垒砖。

这也正是为什么 2014 年 Java 8 推出 Lambda 和静态工厂方法时,整个 Java 社区都欢呼雀跃的原因——它让原本沉重的代码,终于变得像现代编程语言一样轻盈了。

现代写法(推荐)

现代 Java(Java 8+)的优雅玩法:

在过去,写 Comparator 必须写一堆臃肿的匿名内部类。现代 Java 引入了 Lambda 表达式和强大的静态工厂方法,让排序变得像写英语句子一样流畅。

我们用一个 Product(商品)类来演示:

java
public class Product {
  private String name;
  private double price;
  private int sales;

  // 构造方法、Getters 和 toString 略
}
  1. 基础用法:按价格升序

    使用 Comparator.comparing() 配合方法引用,可以直接提取属性进行比较:

    java
    List<Product> products = getProductList(); // 假设获取到了商品列表
    
    // 按价格升序排列
    products.sort(Comparator.comparing(Product::getPrice));
  2. 降序排列:按销量从高到低

    想换成降序?不需要重写逻辑,直接在后面连缀一个 .reversed() 即可:

    java
    // 按销量降序排列(从大到小)
    products.sort(Comparator.comparing(Product::getSales).reversed());
  3. 多条件组合排序:先按价格升序,价格相同按销量降序

    这是电商系统里最常见的组合排序需求。使用 thenComparing 就能像搭积木一样组合规则:

    java
    products.sort(
      Comparator.comparing(Product::getPrice) // 1. 先按价格升序
                .thenComparing(Comparator.comparing(Product::getSales).reversed()) // 2. 价格相同,按销量降序
    );

null 值保护

警惕 NullPointerException(空指针异常):

如果你的商品列表里,某个商品的销量或者价格是 null,直接排序会瞬间抛出 NullPointerException

Comparator 贴心地准备了 nullsFirstnullsLast 方法,来决定把 null 值排在最前还是最后:

java
// 把价格为 null 的商品排在最前面,其余的按价格升序
products.sort(
  Comparator.comparing(Product::getPrice, Comparator.nullsFirst(Double::compare))
);

原始类型优化

原始类型(Primitive)的优化:

如果你要比较的是 intlongdouble 这样的基本数据类型,为了避免频繁自动装箱(Auto-boxing) 带来的性能损耗,建议使用专有的方法:

  • Comparator.comparingInt()

  • Comparator.comparingLong()

  • Comparator.comparingDouble()

    java
    // 性能更好的写法
    products.sort(Comparator.comparingInt(Product::getSales));

Comparable vs Comparator

在 Java 中还有另一个长得很像的接口叫 Comparator,它们经常被拿来对比:

特性Comparable (内部比较器)Comparator (外部比较器)
包位置java.lang.Comparablejava.util.Comparator
核心方法compareTo(T o)compare(T o1, T o2)
对类结构的影响必须修改原类的代码,让原类实现该接口。无需修改原类,可以独立定义一个比较规则类。
灵活度一旦定义,通常是“一劳永逸”的默认/自然规则。非常灵活,可以根据不同场景传入不同的比较规则。
使用场景类的属性比较单一、排序规则固定的情况。原类代码无法修改(如第三方库),或者需要多种排序规则(如今天按价格排,明天按销量排)。